2
原文链接:https://reactjs.org/blog/2018...
React 16.4包含了一个getDerivedStateFromProps的 bug 修复:曾带来一些 React 组件频繁复现的 已有bug。如果你的应用曾经采用某种反模式写法,但是在这次修复之后没有被覆盖到你的情况,我们对于该 bug 深感抱歉。在下文,我们会阐述一些常见的,derived state相关的反模式,还有我们的建议写法。

很长一段时间,componentWillReceiveProps是响应props 改变,不会带来额外重新渲染,更新 state 的唯一方式。在16.3版本中,我们引入了一个生命周期方法getDerivedStateFromProps,为的是以一种更安全的方式来解决同样的问题。同时,我们意识到人们对于这两个钩子函数的使用有许多误解,也发现了一些造成这些晦涩 bug 的反模式。getDerivedStateFromProps的16.4版本修复使得 derived state更稳定,滥用情况会减少一些。

注意事项

本文提及的所有反模式案例面向旧钩子函数componentWillReceiveProps和新钩子函数getDerivedStateFromProps

本文会涵盖下面讨论:

  • 什么时候去使用 derived state
  • 一些 derived state 的常见 bug

    • 反模式:无条件地拷贝props 到state
    • 反模式:当 props 改变的时候清除 state
  • 建议解决方案
  • 内存化

什么时候去使用Derived State

getDerivedStateFromProps存在的唯一目的是使得组件在 props 改变时能都更新好内在state。我们之前的博文有过一些例子,比如基于一个变化着的偏移 prop 来记录当前滚动方向或者根据一个来源 prop 来加载外部数据。

我们没有给出许多例子,因为总体原则上来讲,derived state 应该用少点。我们见过的所有derived state 的问题大多数可以归结为,要么没有任何前提条件的从 props 更新state,要么 props,state 不匹配的任何时候去更新 state。(我们将在下面谈及更多细节)

  • 如果你正在使用 derived state 来进行一些基于当前 props 的内存化计算,那么你不需要 derived state。memoization 小节会细细道来。
  • 如果你在无条件地更新 derived state或者 props,state 不匹配的时候去更新它,你的组件很可能太频繁地重置 state,继续阅读可见分晓。

derived state 的常见 bug

受控,不受控概念通常针对表单输入,但是也可以用来描述组件的数据活动。props 传递进来的数据可以看成受控的(因为父组件控制了数据源)。组件内部状态的数据可以看成不受控的(因为组件能直接改变他)。

最常见的derived state错误 就是混淆两者(受控,不受控数据);当一个 state 的变更字段也可以通过 setState 调用来更新的时候,就没有一个单一的(真相)数据源。上面谈及的加载外部数据的例子可能听起来情况类似,但是一些重要方面还是不一样的。在加载例子中,source 属性和 loading 状态有着一个清晰数据源。当source prop改变的时候,loading 状态总是被重写。相反,loading 状态只会在 prop 改变的时候被重写,其他情况下就是被组件管控着。

问题就是在这些约束变化的时候出现的。最典型的两种形式如下,我们来瞧瞧:

反模式: 无条件的从 props 拷贝至 state

一个常见的误解就是以为getDerivedStateFromPropscomponentWillReceivedProps会只在props 改变的时候被调用。实际上这两个钩子函数可能在父组件渲染的任何时候被调用,不管 props 是不是和以前不同。因此,用这两个钩子函数来无条件消除 state 是不安全的。这样做会使得 state 更新丢失。

我们看看一个范例,这是一个邮箱输入组件,镜像了一个 email prop 到 state:

class EmailInput extends Component {
  state = { email: this.props.email }

  render () {
    return <input onChange={this.handleChange} value={this.state.email} />
  }

  handleChange = e => {
    this.setState({ email: e.target.value })
  }

  componentWillReceiveProps(nextProps) {
    // This will erase any local state updates!
    // Do not do this.
    this.setState({ email: nextProps.email })
  }
}

刚开始,该组件可能看起来 Okay。State 依靠 props 来进行值初始化,我们输入的时候也会更新 State。但是如果父组件重新渲染的时候,我们敲入的任何字符都会被忽略。就算我们在 钩子函数setState 之前进行了nextProps.email !== this.state.email的比较,也无济于事。

在这个简单例子中,我们可以通过增加shouldComponentUpdate,使得只在 email prop改变的时候重新渲染。但是实践表明,组件通常会有多个 prop,另一个 prop的改变仍旧可能造成重新渲染还是有不正确的重置。函数和对象类型的 prop 经常行内生成。使得shouldComponentUpdate只允许在一种情形发生时返回 true很难实现。这儿有个直观例子。所以,shouldComponentUpdate是性能优化的最佳手段,不要想着确保 derived state 的正确使用。

希望现在的你明白了为什么无条件拷贝 props 到 state 是个坏主意。在总结解决方案之前,我们来看看相关反模式:如果我们指向在 email prop 改变的时候去更新 state 呢

反模式: props 改变的时候擦除 state
接着上面例子继续,我们可以避免在 props.email改变的时候故意擦除 state:

class EmailInput extends Component {
  state = {
    email: this.props.email
  }

  componentWillReceiveProps(nextProps) {
    // Any time props.email changes, update state.
    if (nextProps.email !== this.props.email) {
      this.setState({
        email: nextProps.email
      })
    }
  }
}

注意事项

即使上面的例子中只谈到 componentWillReceiveProps, 但是也同样适用于getDerivedStateFromProps

我们已经改善许多,现在组件会只在props 改变的时候清除我们输入过的旧字符。

但是还有一个残留问题。想象一下一个密码控件在使用上述输入框组件,当涉及到拥有同一邮箱的两个帐号的细节式,输入框无法重置。因为 传递给组件的prop值,对于两个帐号而言是一样的。这会困扰到用户,因为一个账号还没保存的变更将会影响到共享同一邮箱的其他帐号。这有demo

这是个根本性的设计失误,但是也很容易犯错,比如我。幸运的是有两个更好的方案。关键在于,对于任何片段数据,需要用一个单独组件来保存数据,并且要避免在其他组件重复。我们来看看这两个方案:

解决方案

推荐方案一:全受控组件

避免上面问题的一个办法,就是从组件当中完全移除 state。如果我们的邮箱地址只是作为一个 prop 存在,那么我们不用担心和 state 的冲突。甚至可以把EmailInput转换成一个更轻量的函数组件:

function EmailInput(props) {
  return <input onChange={props.onChange} value={props.email} />
}

这个办法简化了组件的实现,如果我们仍然想要保存草稿值的话,父表单组件将需要手动处理。这有一个这种模式的demo

推荐方案二: 带有 key 属性的全不受控组件

另一个方案就是我们的组件需要完全控制 draft 邮箱状态值。这样的话,组件仍然可以接受一个prop初始值,但是会忽略该prop 的连续变化:

class EmailInput extends Component {
  state = { email: this.props.defaultEmail }

  handleChange = e => {
    this.setState({ email: e.target.value })
  }

  render () {
    return <input onChange={this.handleChange} value={this.state.email} />
  }
}

在聚焦到另一个表单项的时候为了重置邮箱值(比如密码控件场景),我们可以使用React 的 key 属性。当 key 变化时,React 会创建一个新组件实例,而不是更新当前组件。Keys 通常对于动态列表很有用,不过在这里也很有用。在一个新用户选中时,我们用 user ID 来重新创建一个表单输入框:

<EmailInput
  defaultEmail={this.props.user.email}
  key={this.props.user.id}
/>

每次 ID 改变的时候,EmailInput输入框都会重新生成,它的 state 也就会重置到最新的 defaultEmail值。栗子不能少,这个方案下,没有必要把 key 值添加到每个输入框。在整个form表单上 添加一个 key 属性或许会更合理。每次 key 变化时,表单内的所有组件都会重新生成,同时初始化 state。

在大多数情况,这是处理需要重置的state的最佳办法。

注意事项

这个办法可能听起来性能慢,但是实际表现上可能微不足道。如果一个组件有复杂更新逻辑的话使用key属性可能会更快,因为diffing算法走了弯路
  • 方案一:通过 ID 属性重置 uncontrolled 组件

如果 key 由于某个原因不生效(有可能是组件初始化成本高),那么一个可用但是笨拙的办法就是在getDerivedStateFromProps里监听userID 的变化。

class EmailInput extends Component {
  state = {
    email: this.props.defaulEmail,
    pervPropsUserID: this.props.userID,
  }

  static getDerivedFromProps(nextProps, prevState) {
    // Any time the current user changes,
    // Reset any parts of state that are tied to that user.
    // In this simple example, that's just the email.
    if (nextProps.userID !== prevState.prevPropsUserID) {
      return {
        prevPropsUserID: nextProps.userID,
        email: nextProps.defaultEmail,
      }
    }
    return null
  }

  // ...
}

如果这么做的话,也给只重置组件部分内在状态带来了灵活性,举个例子

注意事项

即使上面的例子中只谈到 getDerivedStateFromProps, 但是也同样适用于componentWillReceiveProps
  • 方案二:用实例方法来重置非受控组件

极少情况下,即使没有用作 key 的合适 ID,你还是想重置 state。一个办法是把 key重置成随机值或者每次你想重置的时候会自动纠正。另一个选择就是用一个实例方法用来命令式地重置内部状态。

class EmailInput extends Component {
  state = {
    email: this.props.defaultEmail,
  }

  resetEmailForNewUser (newEmail) {
    this.setState({ email: newEmail })
  }

  // ...
}

父表单组件就可以使用一个 ref 属性来调用这个方法这里有 Demo.

总结

总结一下,设计一个组件的时候,重要的是确定数据是受控还是不受控。

不要把 prop 值“镜像”到 state,而是要让组件受控,并且合并在一些父组件中的两个分叉值。比如说,不是要让子组件接收一个props.value,并且跟踪一个草稿字段state.value,而是要让父组件管理 state.draftValue还有state.committedValue,直接控制子组件的值。会使得数据流更明显,更稳定。

对于不受控组件,如果你想要在一个 ID 这样的特殊 prop 变化的时候重置 state,你会有以下选项:

  • 推荐:为了重置所有内部state,使用 key 属性
  • 方案一:为了重置某些字段值,监听一个props.userID这种特殊字段的变化
  • 方案二:也可以会退到使用 refs 属性的命令式实例方法

内存化

我们已经看到 derived state 为了确保一个用在 render的字段而在输入框变化时被重新计算。这项技术叫做内存化

使用 derived state 去达到内存化并没有那么糟糕,但是也不是最佳方案。管理 derived state 本身比较复杂,属性变多时变得更复杂了。比如说,如果我们增加第二个 derived 字段到我们的组件 state,那么我们需要针对两个值的变化来做追踪。

看看一个组件例子,它有一个列表 prop,组件渲染出匹配用户查询输入字符的列表选项。我们应该使用 derived state 来存储过滤好的列表。

class Example extends Component {
  state = {
    filterText: '',
  }

  // ********************
  // NOTE: this example is NOT the recommended approach.
  // See the examples below for our recommendations instead.
  // ********************
  staitic getDerivedStateFromProps(nextProps, prevState) {
    // Re-run the filter whenever the list array or filter text change.
    // Note we need to store prePropsList and prevFilterText to detect change.
    if ( nextProps.list !== prevState.prevPropsList || prevState.prevFilterList !== prevState.filterText) {
      return {
        prevPropsList: nextProps.list,
        prevFilterText: prevState.filterText,
        filteredList: nextProps.list.filter(item => item.text.includes(prevState.filterText))
      }
    }
    return null
  }

  handleChange = e => {
    this.setState({ filterText: e.target.value })
  }

  render () {
    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{this.state.filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    )
  }
}

该实现避免了filteredList经常不必要的重新计算。但是也复杂了些。因为需要单独追踪 props和 state 的变化,为的是适当的更新过滤好的列表。这里,我们可以使用PureCompoennt来做简化,把过滤操作放到 render 方法里去:

// PureCompoents only rerender if at least one stae or prop value changes.
// Change is determined by doing a shallow comparison of stae and prop keys.
class Example Extends PureComponent {
  // State only needs to hold the current filter text value:
  state = {
    filterText: '',
  }

  handleChange = e => {
    htis.setState({ filterText: e.target.value })
  }

  render () {
    // The render method on this PureComponent is called only if
    // props.list or state.filterList has changed.
    const filteredList = this.props.list.filter(
      item => item.text.includes(this.stae.filterText)
    )

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    )
  }
}

上面代码要干净多了而且比 derived state 版本要更简单。只是偶尔不够好:对于大列表的过滤有点慢,而且如果另一个 prop 要变化的话PureComponent不会防止重新渲染。基于这样的考虑,我们增加了memoization helper来避免非必要的列表重新过滤:

import memoize from 'memoize-one'

class Example extends Component {
  // State only need to hold the current filter text value:
  state = { filterText: '' }

  filter = memoize(
    (list, filterText) => list.filter(item => item.text.includes(filterText))
  )

  handleChange = e => {
    this.setState({ filterText: e.target.value })
  }

  render () {
    // Calculate the latest filtered list. If these arguments havent changed
    // since the last render, `'memoize-one` will reuse the last return value.
    const filteredList = this.filter(this.props.list, this.sate.filterText)

    return (
      <Fragment>
        <input onChange={this.handleChange} value={this.state.filterText} />
        <ul>{filteredList.map(item => <li key={item.id}>{item.text}</li>)}</ul>
      </Fragment>
    )
  }
}

这要简单多了,而且和 derived state 版本一样好。

当使用memoization的时候,需要满足一些条件:

  1. 在大多数情况下,你会把内存化函数添加到一个组件实例上。这会防止该组件的多个实例重置每一个内存化属性。
  2. 通常你使用一个带有有限缓存大小的内存化工具,为的是防止时间累计下来的内存泄露。(在上述例子中,我们使用memoize-one因为它仅仅会缓存最近的参数和结果)。
  3. 这一节里,如果每次父组件渲染的时候props.list重新生成的话,上述实现会失效。但是在多数情况下,上述实现是合适的。

结束语

在实际应用中,组件经常混合着受控和不受控的行为。理所应当。如果每个值都有明确源,你就可以避免上面的反模式。

重申一下,由于比较复杂,getDerivedStateFromProps(还有 derived state)是一项高级特性,而且应该用少点。如果你使用的时候遇到麻烦,请在 GitHub 或者 Twitter 上联系我们。


南赐
125 声望4 粉丝

大多数情况下会在segmentfault上面活跃,日后主攻小程序,storybook + vue 组件开发,前端测试,持续集成,node.js,eggjs等等。期待与大家深入交流技术~